From cad2b30fdd43dc8fb405c6fdf91eb46a378f821a Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Fri, 5 Dec 2025 21:44:54 +0900 Subject: (김준회) v2 오류수정 및 테스트페이지, 시딩스크립트 추가 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/[lng]/test/table-v2/page.tsx | 630 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 630 insertions(+) create mode 100644 app/[lng]/test/table-v2/page.tsx (limited to 'app/[lng]/test/table-v2/page.tsx') diff --git a/app/[lng]/test/table-v2/page.tsx b/app/[lng]/test/table-v2/page.tsx new file mode 100644 index 00000000..e7fb5bdd --- /dev/null +++ b/app/[lng]/test/table-v2/page.tsx @@ -0,0 +1,630 @@ +"use client"; + +import * as React from "react"; +import { PaginationState, SortingState, ColumnFiltersState, GroupingState } from "@tanstack/react-table"; +import { ClientVirtualTable } from "@/components/client-table-v2/client-virtual-table"; +import { TestProduct } from "@/db/schema/test-table-v2"; +import { productColumns, orderColumns } from "./columns"; +import { OrderWithDetails } from "./column-defs"; +import { + getAllProducts, + getProductTableData, + getOrderTableData, + getProductTableDataWithGrouping, + GroupInfo, +} from "./actions"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { ChevronDown, ChevronRight, Loader2 } from "lucide-react"; +import { cn } from "@/lib/utils"; + +// ============================================================ +// Reusable Loading Overlay Component +// ============================================================ + +function LoadingOverlay({ + isLoading, + children +}: { + isLoading: boolean; + children: React.ReactNode +}) { + return ( +
+ {children} + {isLoading && ( +
+
+ + Loading... +
+
+ )} +
+ ); +} + +// ============================================================ +// Pattern 1: Client-Side Table +// ============================================================ + +function ClientSideTable() { + const [data, setData] = React.useState([]); + const [isLoading, setIsLoading] = React.useState(true); + + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const products = await getAllProducts(); + setData(products); + } catch (error) { + console.error("Failed to fetch products:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, []); + + return ( + + +
+ Pattern 1: Client-Side + fetchMode="client" +
+ + 모든 데이터를 한 번에 받아와 클라이언트에서 필터링/정렬/페이지네이션/그룹핑 처리합니다. +
+ + 적합: 데이터 1000건 이하, 빠른 인터랙션 필요 시 + +
+ + ✅ 그룹핑: 헤더 우클릭 → Group by [Column] + +
+
+ + +
+ +
+
+
+
+ ); +} + +// ============================================================ +// Pattern 2: Factory Service (Server-Side) +// ============================================================ + +function FactoryServiceTable() { + const [data, setData] = React.useState([]); + const [totalRows, setTotalRows] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + + // Table state + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }); + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState([]); + const [globalFilter, setGlobalFilter] = React.useState(""); + + // Fetch data on state change + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getProductTableData({ + pagination, + sorting, + columnFilters, + globalFilter, + }); + setData(result.data); + setTotalRows(result.totalRows); + } catch (error) { + console.error("Failed to fetch products:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, sorting, columnFilters, globalFilter]); + + return ( + + +
+ Pattern 2: Factory Service + fetchMode="server" + createTableService +
+ + createTableService로 서버 액션을 자동 생성합니다. +
+ + 적합: 단순 CRUD, 마스터 테이블 조회 + +
+ + ⚠️ 그룹핑: 서버 모드에서는 별도 구현 필요 (Pattern 2-B 참고) + +
+
+ + +
+ +
+
+
+
+ ); +} + +// ============================================================ +// Pattern 2-B: Server-Side Grouping (Context Menu 방식) +// ============================================================ + +function ServerGroupingTable() { + const [grouping, setGrouping] = React.useState([]); + const [expandedGroups, setExpandedGroups] = React.useState([]); + const [groups, setGroups] = React.useState([]); + const [flatData, setFlatData] = React.useState([]); + const [isGrouped, setIsGrouped] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(true); + const [totalRows, setTotalRows] = React.useState(0); + + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }); + + // 데이터 페칭 + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getProductTableDataWithGrouping( + { pagination, grouping }, + expandedGroups + ); + + if ('groups' in result) { + setGroups(result.groups); + setIsGrouped(true); + setFlatData([]); + } else { + setFlatData(result.data); + setTotalRows(result.totalRows); + setIsGrouped(false); + setGroups([]); + } + } catch (error) { + console.error("Failed to fetch:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, grouping, expandedGroups]); + + // 그룹 토글 + const toggleGroup = (groupKey: string) => { + setExpandedGroups(prev => + prev.includes(groupKey) + ? prev.filter(k => k !== groupKey) + : [...prev, groupKey] + ); + }; + + // 그룹핑 상태 변경 핸들러 (Context Menu에서 호출됨) + const handleGroupingChange = React.useCallback((updater: GroupingState | ((old: GroupingState) => GroupingState)) => { + const newGrouping = typeof updater === 'function' ? updater(grouping) : updater; + setGrouping(newGrouping); + setExpandedGroups([]); // 그룹핑 변경 시 확장 상태 초기화 + }, [grouping]); + + return ( + + +
+ Pattern 2-B: Server-Side Grouping + fetchMode="server" + GROUP BY +
+ + 서버에서 GROUP BY + 집계 쿼리로 그룹 정보를 조회합니다. +
+ + ✅ 그룹핑: 헤더 우클릭 → Group by [Column] (category, status, isNew만 지원) + +
+
+ + {/* 현재 그룹핑 상태 표시 */} + {grouping.length > 0 && ( +
+ Grouped by: + {grouping.map((col) => ( + + {col} + + + ))} +
+ )} + + {/* Content with Loading Overlay */} + +
+ {isGrouped ? ( + // Grouped View - Custom Rendering +
+ {groups.length === 0 ? ( +
+ No data +
+ ) : ( + groups.map((group) => ( +
+ {/* Group Header */} + + + {/* Expanded Rows */} + {expandedGroups.includes(group.groupKey) && group.rows && ( +
+ + + + + + + + + + + + {group.rows.map((row) => ( + + + + + + + + ))} + +
IDSKUNamePriceStock
{row.id}{row.sku}{row.name} + {new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + }).format(parseFloat(row.price))} + {row.stock}
+
+ )} +
+ )) + )} +
+ ) : ( + // Normal Table View with Context Menu Grouping + + )} +
+
+
+
+ ); +} + +// ============================================================ +// Pattern 3: Custom Service (Server-Side with Joins) +// ============================================================ + +function CustomServiceTable() { + const [data, setData] = React.useState([]); + const [totalRows, setTotalRows] = React.useState(0); + const [isLoading, setIsLoading] = React.useState(true); + + // Table state + const [pagination, setPagination] = React.useState({ + pageIndex: 0, + pageSize: 10, + }); + const [sorting, setSorting] = React.useState([]); + const [columnFilters, setColumnFilters] = React.useState([]); + const [globalFilter, setGlobalFilter] = React.useState(""); + + // Fetch data on state change + React.useEffect(() => { + const fetchData = async () => { + setIsLoading(true); + try { + const result = await getOrderTableData({ + pagination, + sorting, + columnFilters, + globalFilter, + }); + setData(result.data); + setTotalRows(result.totalRows); + } catch (error) { + console.error("Failed to fetch orders:", error); + } finally { + setIsLoading(false); + } + }; + + fetchData(); + }, [pagination, sorting, columnFilters, globalFilter]); + + return ( + + +
+ Pattern 3: Custom Service + fetchMode="server" + DrizzleTableAdapter +
+ + DrizzleTableAdapter를 도구로 사용하여 복잡한 조인 쿼리를 직접 작성합니다. +
+ + 적합: 여러 테이블 조인, 복잡한 비즈니스 로직 + +
+ + ⚠️ 그룹핑: 가상 컬럼(조인 결과)은 서버 GROUP BY 불가 + +
+
+ + +
+ +
+
+
+
+ ); +} + +// ============================================================ +// Main Page +// ============================================================ + +export default function TableV2TestPage() { + return ( +
+
+

+ ClientVirtualTable V2 - 데이터 페칭 패턴 테스트 +

+

+ GUIDE.md에 정의된 데이터 페칭 패턴과 그룹핑 처리 방법을 테스트합니다. +
+ 테스트 전 시딩이 필요합니다: npx tsx db/seeds/test-table-v2.ts +

+
+ + + + + 1. Client-Side + + + 2. Factory Service + + + 2-B. Server Grouping + + + 3. Custom Service + + + + + + + + + + + + + + + + + + + + + {/* Summary Table */} + + + 패턴별 그룹핑 지원 현황 + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
패턴그룹핑 방식가상 컬럼 지원비고
1. Client-Side + TanStack Grouping + + ✓ 지원 + + 메모리에서 처리, 전체 데이터 필요 +
2. Factory Service + 미지원 + - + 별도 구현 필요 (2-B 참고) +
2-B. Server Grouping + DB GROUP BY + + ✗ 불가 + + serverGroupable 컬럼만 가능 +
3. Custom Service + 커스텀 구현 + + 선택적 + + 쿼리 설계에 따라 다름 +
+
+
+
+ + {/* Column Groupability Info */} + + + 컬럼별 서버 그룹핑 지원 여부 + + meta.serverGroupable 플래그로 DB GROUP BY 가능 여부를 표시합니다. +
+ 헤더 우클릭 시 "Group by [Column]" 메뉴가 표시됩니다. +
+
+ +
+ {productColumns.map((col) => { + if (!('accessorKey' in col)) return null; + const meta = col.meta as { serverGroupable?: boolean } | undefined; + const isGroupable = meta?.serverGroupable; + return ( + + {col.accessorKey as string} + {isGroupable && " ✓"} + + ); + })} +
+
+
+
+ ); +} -- cgit v1.2.3 From 211b5dbdda47c4bb19f3ebc274253b21e1b81bc4 Mon Sep 17 00:00:00 2001 From: joonhoekim <26rote@gmail.com> Date: Mon, 8 Dec 2025 01:46:14 +0900 Subject: (김준회) 서버사이드 페칭 예시 소팅 오류 수정 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/evcp-project-rules.mdc | 22 +++++ .cursor/rules/table-guide.mdc | 4 + app/[lng]/test/table-v2/actions.ts | 29 ++++++- app/[lng]/test/table-v2/page.tsx | 11 ++- components/client-table-v2/GUIDE-v2.md | 93 ++++++++++++++++++++++ components/client-table-v2/GUIDE-v3-ko.md | 92 +++++++++++++++++++++ components/client-table-v2/GUIDE-v3.md | 93 ++++++++++++++++++++++ components/client-table-v2/client-table-preset.tsx | 3 + 8 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 .cursor/rules/evcp-project-rules.mdc create mode 100644 .cursor/rules/table-guide.mdc create mode 100644 components/client-table-v2/GUIDE-v2.md create mode 100644 components/client-table-v2/GUIDE-v3-ko.md create mode 100644 components/client-table-v2/GUIDE-v3.md (limited to 'app/[lng]/test/table-v2/page.tsx') diff --git a/.cursor/rules/evcp-project-rules.mdc b/.cursor/rules/evcp-project-rules.mdc new file mode 100644 index 00000000..cbd3e44d --- /dev/null +++ b/.cursor/rules/evcp-project-rules.mdc @@ -0,0 +1,22 @@ +--- +alwaysApply: true +--- +1. tech stacks: nextjs 15, postgres 17 with drizzle-orm, shadcn-ui, react 18.3.1 full stack +2. user info: Intermediate English. Hardcoded text and comment can be written in English. + +specific: +- Above Nextjs 15, exported server action functions should be async function. +- Do not use shadcn ui ScrollArea Function in Dialog component. (It has error now.) +- Most packages are already installed. If package installation required, check package.json file first. + +design: +- If you can suppose some design patterns for solve the problem in prompt, notify it. +- Check component/common/* for shared components. +For these tasks, instruct the user rather than doing + +limit: +- About CLI task, just notify. User will operate CLI. For example, 'npx drizzle-kit generate & migrate', 'npm run dev'. +- You can't read and edit .env.* files. + +limit-solution: +- For limited tasks, instruct the user rather than doing them yourself. \ No newline at end of file diff --git a/.cursor/rules/table-guide.mdc b/.cursor/rules/table-guide.mdc new file mode 100644 index 00000000..7b240413 --- /dev/null +++ b/.cursor/rules/table-guide.mdc @@ -0,0 +1,4 @@ +--- +alwaysApply: false +--- +If table management is required, see @/components/client-table-v2/GUIDE-v3.md \ No newline at end of file diff --git a/app/[lng]/test/table-v2/actions.ts b/app/[lng]/test/table-v2/actions.ts index e1737083..f5fd5f66 100644 --- a/app/[lng]/test/table-v2/actions.ts +++ b/app/[lng]/test/table-v2/actions.ts @@ -5,7 +5,7 @@ import { testProducts, testOrders, testCustomers } from "@/db/schema/test-table- import { createTableService } from "@/components/client-table-v2/adapter/create-table-service"; import { DrizzleTableState } from "@/components/client-table-v2/adapter/drizzle-table-adapter"; import { productColumnDefs, OrderWithDetails, ServerColumnMeta } from "./column-defs"; -import { count, eq, desc, sql, asc } from "drizzle-orm"; +import { SQL, count, eq, desc, sql, asc } from "drizzle-orm"; import { TestProduct } from "@/db/schema/test-table-v2"; // ============================================================ @@ -182,6 +182,31 @@ export async function getOrderTableData(tableState: DrizzleTableState): Promise< const limit = pageSize; const offset = pageIndex * pageSize; + // Build ORDER BY clause based on sorting state + const orderByClauses = + tableState.sorting?.reduce[]>((clauses, sort) => { + const columnMap: Record = { + id: testOrders.id, + orderNumber: testOrders.orderNumber, + quantity: testOrders.quantity, + unitPrice: testOrders.unitPrice, + totalAmount: testOrders.totalAmount, + status: testOrders.status, + orderedAt: testOrders.orderedAt, + customerName: testCustomers.name, + customerEmail: testCustomers.email, + customerTier: testCustomers.tier, + productName: testProducts.name, + productSku: testProducts.sku, + }; + + const column = columnMap[sort.id]; + if (!column) return clauses; + + clauses.push(sort.desc ? desc(column) : asc(column)); + return clauses; + }, []) ?? []; + // 커스텀 조인 쿼리 작성 const data = await db .select({ @@ -203,7 +228,7 @@ export async function getOrderTableData(tableState: DrizzleTableState): Promise< .from(testOrders) .leftJoin(testCustomers, eq(testOrders.customerId, testCustomers.id)) .leftJoin(testProducts, eq(testOrders.productId, testProducts.id)) - .orderBy(desc(testOrders.orderedAt)) + .orderBy(...(orderByClauses.length > 0 ? orderByClauses : [desc(testOrders.orderedAt)])) .limit(limit) .offset(offset); diff --git a/app/[lng]/test/table-v2/page.tsx b/app/[lng]/test/table-v2/page.tsx index e7fb5bdd..65c0ee1d 100644 --- a/app/[lng]/test/table-v2/page.tsx +++ b/app/[lng]/test/table-v2/page.tsx @@ -99,6 +99,8 @@ function ClientSideTable() { enablePagination enableGrouping height="100%" + enableUserPreset={true} + tableKey="test-table-v2-pattern1" /> @@ -188,6 +190,8 @@ function FactoryServiceTable() { onColumnFiltersChange={setColumnFilters} globalFilter={globalFilter} onGlobalFilterChange={setGlobalFilter} + enableUserPreset={true} + tableKey="test-table-v2-pattern-2-A" /> @@ -208,6 +212,7 @@ function ServerGroupingTable() { const [isGrouped, setIsGrouped] = React.useState(false); const [isLoading, setIsLoading] = React.useState(true); const [totalRows, setTotalRows] = React.useState(0); + const [sorting, setSorting] = React.useState([]); const [pagination, setPagination] = React.useState({ pageIndex: 0, @@ -220,7 +225,7 @@ function ServerGroupingTable() { setIsLoading(true); try { const result = await getProductTableDataWithGrouping( - { pagination, grouping }, + { pagination, grouping, sorting }, expandedGroups ); @@ -242,7 +247,7 @@ function ServerGroupingTable() { }; fetchData(); - }, [pagination, grouping, expandedGroups]); + }, [pagination, grouping, sorting, expandedGroups]); // 그룹 토글 const toggleGroup = (groupKey: string) => { @@ -374,6 +379,8 @@ function ServerGroupingTable() { height="400px" pagination={pagination} onPaginationChange={setPagination} + sorting={sorting} + onSortingChange={setSorting} // 그룹핑 상태 연결 grouping={grouping} onGroupingChange={handleGroupingChange} diff --git a/components/client-table-v2/GUIDE-v2.md b/components/client-table-v2/GUIDE-v2.md new file mode 100644 index 00000000..930123fb --- /dev/null +++ b/components/client-table-v2/GUIDE-v2.md @@ -0,0 +1,93 @@ +# ClientVirtualTable V2 — Server Fetching Guide + +This guide focuses on `fetchMode="server"` usage (Tabs 2, 2-B, 3 in `/[lng]/test/table-v2`). Client mode is unchanged from `GUIDE.md`. + +## Core Concepts +- `fetchMode="server"` sets `manualPagination|manualSorting|manualFiltering|manualGrouping` to true. The table **renders what the server returns**; no client-side sorting/filtering/pagination is applied. +- You must control table state (pagination, sorting, filters, grouping, globalFilter) in the parent and refetch on change. +- Provide `rowCount` (and optionally `pageCount`) so the pagination footer is accurate. +- Export uses the current row model; in server mode it only exports the loaded page unless you fetch everything yourself. + +## Minimal Wiring (Factory Service) +```tsx +const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); +const [sorting, setSorting] = useState([]); +const [columnFilters, setColumnFilters] = useState([]); +const [globalFilter, setGlobalFilter] = useState(""); +const [data, setData] = useState([]); +const [rowCount, setRowCount] = useState(0); +const [loading, setLoading] = useState(false); + +useEffect(() => { + const run = async () => { + setLoading(true); + const res = await getTableData({ + pagination, + sorting, + columnFilters, + globalFilter, + }); + setData(res.data); + setRowCount(res.totalRows); + setLoading(false); + }; + run(); +}, [pagination, sorting, columnFilters, globalFilter]); + + +``` + +## Using `createTableService` (Pattern 2) +- Import `createTableService` in a server action and pass `columns` (accessorKey-based) plus schema/db. +- The adapter maps `sorting`, `columnFilters`, `globalFilter`, `pagination` to Drizzle query parts. +- Returned shape: `{ data, totalRows, pageCount }`. Always forward `totalRows` to the client. + +## Custom Service (Pattern 3) +- Build custom joins manually; still read `tableState` for pagination/sorting/filtering if you need them. +- For sorting: map `tableState.sorting` IDs to your joined columns; provide a default order if none is set. +- Grouping in custom services requires manual implementation (see `getOrderTableDataGroupedByStatus` pattern). + +## Server Grouping (Pattern 2-B) +- Only columns marked `meta.serverGroupable` in server column defs should be used. +- Group headers are fetched via DB `GROUP BY`; expanded rows are fetched per group. +- When grouping is active, the table may render a custom grouped view instead of the virtual table; ensure your fetcher returns either `{ groups }` or `{ data, totalRows }`. + +## Presets in Server Mode +- Presets store: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize. +- Loading a preset triggers the table’s `set*` APIs; parent `on*Change` handlers refetch with the restored state. +- The component resets pageIndex to 0 when applying a preset to avoid out-of-range requests after pageSize changes. +- Use unique `tableKey` per screen to avoid clashing presets across pages. + +## Common Pitfalls +- Forgetting `rowCount` → pagination shows wrong totals. +- Not reacting to `sorting`/`filters`/`grouping` changes in your effect → UI toggles with no data change. +- Mapping `sorting` IDs to columns incorrectly in custom services → server ignores the sort. +- Mixing client-side models with server mode: do not enable client `getSortedRowModel`/`getFilteredRowModel` for server fetches (the component already skips them when `fetchMode="server"`). + +## Feature Matrix (Server Mode) +- Sorting: Supported; must be implemented in the server fetcher. +- Filtering: Supported; column filters/global filter forwarded; implement in server. +- Pagination: Supported; manual; provide `rowCount`. +- Grouping: Client grouping is off in server mode; implement via server `GROUP BY` or custom grouped view. +- Column show/hide, pinning, reorder: Client-side only; state is preserved and sent to presets but does not affect server queries unless you opt to read it. +- Export: Exports the currently loaded rows; fetch all data yourself for full exports. + +## Debug Checklist +- Confirm `fetchMode="server"` and `rowCount` are set. +- Verify the parent effect depends on `pagination`, `sorting`, `columnFilters`, `globalFilter`, and (if used) `grouping`. +- In custom services, console/log the incoming `tableState` to confirm the UI is sending the intended state. diff --git a/components/client-table-v2/GUIDE-v3-ko.md b/components/client-table-v2/GUIDE-v3-ko.md new file mode 100644 index 00000000..9ec71065 --- /dev/null +++ b/components/client-table-v2/GUIDE-v3-ko.md @@ -0,0 +1,92 @@ +# ClientVirtualTable V3 가이드 (한국어) + +`components/client-table-v2` 테이블 컴포넌트와 `fetchMode="server"` 사용 시 주의점을 정리했습니다. + +## 모듈 맵 +- `client-virtual-table.tsx`: 코어 테이블(가상 스크롤, 컬럼 DnD, 핀/숨김, 프리셋, 툴바, 페이지네이션). +- `client-table-column-header.tsx`: 헤더 셀(정렬 토글, 필터 UI, 컨텍스트 메뉴: 핀/숨김/그룹/재정렬). +- `client-table-toolbar.tsx` (client-table): 검색, 내보내기, 뷰 옵션, 프리셋 엔트리. +- `client-table-view-options.tsx` (client-table): 컬럼 표시/숨김 토글. +- `client-table-filter.tsx`: 컬럼 필터 UI(text/select/boolean). +- `client-table-preset.tsx`: `tableKey`+사용자별 프리셋 저장/불러오기/삭제. +- 기타: `export-utils`, `import-utils`, `ClientDataTablePagination`(client-data-table). +- 서버 헬퍼: `adapter/create-table-service.ts`, `adapter/drizzle-table-adapter.ts`. +- 타입: `types.ts`, `preset-types.ts`. + +## 핵심 동작 (ClientVirtualTable) +- 가상 스크롤: `height` 필수, `estimateRowHeight` 기본 40. +- DnD: 컬럼 재배치, 핀 섹션 간 이동 시 핀 상태 동기화. +- 핀/숨김/순서: 클라이언트 상태(`columnVisibility`, `columnPinning`, `columnOrder`). +- 정렬/필터/페이지네이션/그룹핑 + - `fetchMode="client"`: TanStack 모델 사용. + - `fetchMode="server"`: manual 플래그 on, 클라이언트 모델 skip → **서버가 정렬/필터/페이징된 결과를 반환해야 함**. +- 내보내기: 현재 렌더된 행 기준. 서버 모드에서 전체 내보내기는 직접 `onExport`로 구현 필요. +- 프리셋: `enableUserPreset`+`tableKey` 설정 시 표시. 불러올 때 pageIndex를 0으로 리셋해 서버 모드에서 범위 오류 방지. + +## 주요 Props +- `fetchMode`: `"client"` | `"server"` (기본 `"client"`). +- 데이터: `data`, `rowCount?`, `pageCount?`. +- 상태/핸들러: + - 페이지: `pagination`, `onPaginationChange`, `enablePagination`, `manualPagination?`. + - 정렬: `sorting`, `onSortingChange`. + - 필터: `columnFilters`, `onColumnFiltersChange`, `globalFilter`, `onGlobalFilterChange`. + - 그룹핑: `grouping`, `onGroupingChange`, `expanded`, `onExpandedChange`, `enableGrouping`. + - 표시/핀/순서: `columnVisibility`, `columnPinning`, `columnOrder` 및 각 onChange. + - 선택: `enableRowSelection`, `enableMultiRowSelection`, `rowSelection`, `onRowSelectionChange`. +- UX: `actions`, `customToolbar`, `enableExport`, `onExport`, `renderHeaderVisualFeedback`, `getRowClassName`, `onRowClick`. +- 프리셋: `enableUserPreset`, `tableKey`. +- 메타: `meta`, `getRowId`. + +## 서버 페칭 패턴 +### 패턴 1: 클라이언트 모드 +- `fetchMode="client"`, 전체 데이터 전달. 정렬/필터/그룹핑은 브라우저에서 처리. + +### 패턴 2: Factory Service (`createTableService`) +- 서버 액션: `createTableService({ db, schema, columns, defaultWhere?, customQuery? })`. +- 어댑터가 `sorting`, `columnFilters`, `globalFilter`, `pagination`, `grouping`을 Drizzle `where/orderBy/limit/offset/groupBy`로 변환. +- 반환 `{ data, totalRows, pageCount }` → 클라이언트에서 `rowCount` 설정 필수. +- 클라이언트: `pagination/sorting/columnFilters/globalFilter` 제어 후 deps로 `useEffect` 재호출. + +### 패턴 2-B: 서버 그룹핑 +- `getProductTableDataWithGrouping` 예시: `grouping` 없으면 일반 페칭, 있으면 DB `GROUP BY` 후 `{ groups }` 반환. +- 서버 그룹핑 가능한 컬럼(`meta.serverGroupable`)만 사용. +- 그룹 확장 시 그룹 키별 하위 행을 추가 조회, 그룹 변경 시 확장 상태 초기화. +- 그룹뷰 렌더 시 가상 테이블 대신 커스텀 블록을 사용할 수 있음. + +### 패턴 3: 커스텀 서비스 +- 조인/파생 컬럼용. `tableState`를 읽어 정렬 ID를 조인 컬럼에 매핑, 정렬 없을 때 기본 정렬 제공. +- 필터/글로벌 필터는 직접 구현해야 함. +- 그룹핑도 수동 구현(`getOrderTableDataGroupedByStatus` 참고). + +## 상태 → 쿼리 매핑 (서버) +- 정렬: `tableState.sorting`(id, desc) → DB 컬럼 매핑, 모르는 id는 무시. +- 필터: 텍스트(ilike), 불리언, 숫자, 범위[min,max], 다중선택(IN) 지원. +- 글로벌 필터: 매핑된 컬럼 OR ilike. +- 페이지: pageIndex/pageSize → limit/offset, `rowCount` 반환. +- 그룹핑: 지원 컬럼만 `GROUP BY`. + +## 프리셋 (서버 호환) +- 저장: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize. +- 불러오기: `table.set*` 호출 + pageIndex 0 리셋 → 상위 `on*Change` 핸들러에서 재페칭. +- 화면별 고유 `tableKey` 사용 권장. 세션 필요. + +## 기능 매트릭스 (서버 모드) +- 정렬: 지원 (서버 구현 필요) +- 필터: 지원 (서버 구현 필요) +- 페이지네이션: 지원 (manual, `rowCount` 필요) +- 그룹핑: 자동 미지원, 서버 그룹핑 또는 커스텀 뷰로 구현 +- 컬럼 숨김/핀/순서: 클라이언트 전용(시각용), 서버 쿼리에 자동 반영 안 함 +- 내보내기: 로드된 행만; 전체 내보내기는 커스텀 `onExport` 필요 + +## 구현 팁 +- `fetchMode="server"`일 때 `rowCount` 꼭 설정. +- `pagination/sorting/columnFilters/globalFilter/(grouping)` 변경 시마다 재페칭. +- 정렬 없을 때 서버 기본 정렬을 지정. +- 그룹 변경 시 확장 상태 초기화. +- `height`를 항상 지정(가상 스크롤 컨테이너 필요). + +## 빠른 예시 +- 클라이언트: `fetchMode="client"`, 전체 데이터 전달, 그룹핑 옵션 사용 가능. +- Factory 서버: `fetchMode="server"`, `createTableService`, 제어형 상태 + `rowCount`. +- 서버 그룹핑: `grouping`에 따라 `{ groups }` vs `{ data }` 반환, `serverGroupable` 컬럼만 허용. +- 커스텀 조인: 정렬 ID 직접 매핑, 필터/글로벌 직접 적용, `rowCount` 반환. diff --git a/components/client-table-v2/GUIDE-v3.md b/components/client-table-v2/GUIDE-v3.md new file mode 100644 index 00000000..21a1217d --- /dev/null +++ b/components/client-table-v2/GUIDE-v3.md @@ -0,0 +1,93 @@ +# ClientVirtualTable V3 Guide + +This guide documents the table components in `components/client-table-v2`, with an emphasis on server fetching (`fetchMode="server"`) and how supporting components fit together. + +## Module Map +- `client-virtual-table.tsx`: Core table (virtualized, DnD columns, pin/hide, presets hook point, toolbar, pagination). +- `client-table-column-header.tsx`: Header cell with sort toggle, filter UI, context menu (hide/pin/group/reorder hook). +- `client-table-toolbar.tsx` (from `components/client-table`): Search box, export button, view options, preset entry point. +- `client-table-view-options.tsx` (from `components/client-table`): Column visibility toggles. +- `client-table-filter.tsx`: Column filter UI (text/select/boolean). +- `client-table-preset.tsx`: Save/load/delete presets per `tableKey` + user. +- `client-table-save-view.tsx`, `client-table-preset.tsx`, `client-table-toolbar.tsx`: Preset and view controls. +- `client-virtual-table` dependencies: `ClientDataTablePagination` (`components/client-data-table`), `export-utils`, `import-utils`. +- Server helpers: `adapter/create-table-service.ts`, `adapter/drizzle-table-adapter.ts`. +- Types: `types.ts`, `preset-types.ts`. + +## Core Behaviors (ClientVirtualTable) +- Virtualization: `height` is required; `estimateRowHeight` defaults to 40. +- Drag & Drop: Columns reorder across pin sections; drag between pin states updates pinning. +- Pin/Hide/Reorder: Managed client-side; state is exposed via `columnVisibility`, `columnPinning`, `columnOrder`. +- Sorting/Filtering/Pagination/Grouping: + - `fetchMode="client"`: uses TanStack models (`getSortedRowModel`, `getFilteredRowModel`, `getPaginationRowModel`, etc.). + - `fetchMode="server"`: sets manual flags true and skips client models; **server must return already-sorted/filtered/paged data**. +- Export: Uses current row model; in server mode it exports only the loaded rows unless you supply all data yourself via `onExport`. +- Presets: When `enableUserPreset` and `tableKey` are set, toolbar shows the preset control; loading a preset resets pageIndex to 0 to avoid invalid pages on server mode. + +## Key Props (ClientVirtualTable) +- `fetchMode`: `"client"` | `"server"` (default `"client"`). +- Data: `data`, `rowCount?`, `pageCount?`. +- State + handlers (controlled or uncontrolled): + - Pagination: `pagination`, `onPaginationChange`, `enablePagination`, `manualPagination?`. + - Sorting: `sorting`, `onSortingChange`. + - Filters: `columnFilters`, `onColumnFiltersChange`, `globalFilter`, `onGlobalFilterChange`. + - Grouping: `grouping`, `onGroupingChange`, `expanded`, `onExpandedChange`, `enableGrouping`. + - Visibility/Pinning/Order: `columnVisibility`, `onColumnVisibilityChange`, `columnPinning`, `onColumnPinningChange`, `columnOrder`, `onColumnOrderChange`. + - Selection: `enableRowSelection`, `enableMultiRowSelection`, `rowSelection`, `onRowSelectionChange`. +- UX: `actions`, `customToolbar`, `enableExport`, `onExport`, `renderHeaderVisualFeedback`, `getRowClassName`, `onRowClick`. +- Presets: `enableUserPreset`, `tableKey`. +- Meta: `meta`, `getRowId`. + +## Server Fetching Patterns +### Pattern 1: Client-Side (baseline) +- `fetchMode="client"`, pass full dataset; TanStack handles sorting/filtering/grouping locally. + +### Pattern 2: Factory Service (`createTableService`) +- Server action: `createTableService({ db, schema, columns, defaultWhere?, customQuery? })`. +- The adapter maps `sorting`, `columnFilters`, `globalFilter`, `pagination`, `grouping` → Drizzle `where`, `orderBy`, `limit`, `offset`, `groupBy`. +- Returns `{ data, totalRows, pageCount }`; always forward `totalRows` to the client and wire `rowCount`. +- Client wiring: control `pagination`, `sorting`, `columnFilters`, `globalFilter`; refetch in `useEffect` on those deps. + +### Pattern 2-B: Server Grouping +- Uses `getProductTableDataWithGrouping` sample: if `grouping` is empty → normal server fetch; else returns `{ groups }` built from DB `GROUP BY`. +- Columns must be marked `meta.serverGroupable` in server column defs. +- Expanded groups fetch child rows per group key; grouping change clears expanded state. +- UI may render a custom grouped view (not the virtual table) when grouped. + +### Pattern 3: Custom Service +- For joins/derived columns: read `tableState` and manually map `sorting` IDs to joined columns; supply a default order when no sort is present. +- Filtering/global filter are not automatic—implement them if needed. +- Grouping is manual; see `getOrderTableDataGroupedByStatus` pattern for a grouped response shape. + +## State → Query Mapping (Server) +- Sorting: `tableState.sorting` (id, desc) → map to DB columns; ignore unknown ids. +- Filters: `columnFilters` supports text (ilike), boolean, number, range `[min,max]`, multi-select (IN). +- Global filter: ilike OR across mapped columns. +- Pagination: pageIndex/pageSize → limit/offset; return `rowCount`. +- Grouping: `grouping` → `GROUP BY` for supported columns only. + +## Presets (Server-Friendly) +- Saved keys: sorting, columnFilters, globalFilter, columnVisibility, columnPinning, columnOrder, grouping, pageSize. +- On load: applies `table.set*` and resets pageIndex to 0; parent `on*Change` handlers should trigger refetch. +- Use unique `tableKey` per screen to avoid collisions; requires authenticated session. + +## Feature Matrix (Server Mode) +- Sorting: Yes—server implemented. +- Filtering: Yes—server implemented. +- Pagination: Yes—manual; provide `rowCount`. +- Grouping: Not automatic; implement via server grouping or custom grouped view. +- Column hide/pin/reorder: Client-only (visual); does not change server query unless you opt to read it. +- Export: Only current rows unless you provide `onExport` with full data. + +## Implementation Tips +- Always set `rowCount` when `fetchMode="server"`. +- Refetch on `pagination`, `sorting`, `columnFilters`, `globalFilter`, and `grouping` (if used). +- Provide a default sort on the server when `sorting` is empty. +- Reset `expanded` or group expand state when grouping changes in server grouping flows. +- Ensure `height` is set; virtualization needs a scroll container. + +## Quick Examples +- Client: `fetchMode="client"` with `data` = full list; optional grouping enabled. +- Factory server: `fetchMode="server"`, `createTableService` action, controlled state with `rowCount`. +- Server grouping: `grouping` drives `{ groups }` vs `{ data }` response; only `serverGroupable` columns allowed. +- Custom join: Manually map `sorting` ids; apply filters/global; return `rowCount`. diff --git a/components/client-table-v2/client-table-preset.tsx b/components/client-table-v2/client-table-preset.tsx index 64930e7a..21486c9b 100644 --- a/components/client-table-v2/client-table-preset.tsx +++ b/components/client-table-v2/client-table-preset.tsx @@ -108,6 +108,9 @@ export function ClientTablePreset({ if (s.columnOrder) table.setColumnOrder(s.columnOrder); if (s.grouping) table.setGrouping(s.grouping); if (s.pagination?.pageSize) table.setPageSize(s.pagination.pageSize); + // Reset page index to avoid loading an out-of-range page after applying a preset, + // which is especially important in server-mode pagination. + table.setPageIndex(0); toast.success(`Preset "${preset.name}" loaded`); }; -- cgit v1.2.3